Skip to content

fix(mcp): normalize server names, fix UTF-8 truncation, skip auth when header set#2379

Open
zmanian wants to merge 5 commits intostagingfrom
fix/mcp-client-bugs
Open

fix(mcp): normalize server names, fix UTF-8 truncation, skip auth when header set#2379
zmanian wants to merge 5 commits intostagingfrom
fix/mcp-client-bugs

Conversation

@zmanian
Copy link
Copy Markdown
Collaborator

@zmanian zmanian commented Apr 12, 2026

Summary

Fixes three related MCP client bugs in a single PR:

Test plan

  • test_normalize_server_name_* - 5 tests covering basic, uppercase, spaces/special chars, empty/all-special, and leading/trailing underscores
  • test_tool_description_truncation_multibyte_utf8 - regression test with CJK characters where byte index 57 falls mid-character
  • test_tool_description_truncation_ascii - ASCII descriptions still truncate correctly
  • test_requires_auth_skipped_with_custom_auth_header - Authorization header skips auth
  • test_requires_auth_skipped_with_lowercase_auth_header - case-insensitive check
  • test_requires_auth_still_true_with_non_auth_headers - non-Authorization headers don't suppress auth
  • cargo clippy --all --all-features passes with zero warnings
  • All 43 MCP config tests + 9 CLI MCP tests pass

Closes #2236, closes #1947, closes #1948

Generated with Claude Code

@github-actions github-actions bot added size: XL 500+ changed lines scope: channel Channel infrastructure scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: tool Tool infrastructure scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: dependencies Dependency updates risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs and removed size: XL 500+ changed lines labels Apr 12, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Ethereum wallet integration via WalletConnect v2, adding tools for wallet pairing and transaction submission. It also includes several bug fixes for MCP server name normalization, UTF-8 safe string truncation, and OAuth flow logic. However, the current implementation has critical architectural issues: holding a MutexGuard across an await point in the event loop will cause deadlocks, and the wallet_transact tool is currently non-functional as it registers a callback without actually initiating the transaction request. Additionally, git dependencies should be pinned to specific commit hashes, and account address extraction should avoid relying on Debug formatting.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
Comment on lines +77 to +79
if let Some(ref wc) = *client {
match wc.next().await {
Ok(event) => event,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The MutexGuard is held across an .await point in poll_event. Since wc.next().await can block indefinitely while waiting for events from the relay, this will prevent any other task from acquiring the lock. This effectively deadlocks other operations like request(), which also needs to acquire the same lock. Consider an architecture where the client is not locked during long-running awaits, or use a split reader/writer pattern if supported by the underlying library.

Comment thread src/tools/builtin/ethereum/transact.rs Outdated
Comment on lines +104 to +170
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();

// Must be paired first.
if !self.session.is_paired().await {
return Err(EthereumError::NotPaired.into());
}

// Extract and validate parameters.
let to = require_str(&params, "to")?;
if !is_valid_eth_address(to) {
return Err(EthereumError::InvalidAddress {
address: to.to_string(),
}
.into());
}

let value = require_str(&params, "value")?;
let data = params.get("data").and_then(|v| v.as_str());
let chain_id = params.get("chain_id").and_then(|v| v.as_u64());

// Generate a correlation ID for the async callback.
let correlation_id = uuid::Uuid::new_v4().to_string();

// Extract channel/thread from context metadata for callback routing.
let channel = ctx
.metadata
.get("source_channel")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let thread_id = ctx
.metadata
.get("thread_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

// Register callback so the result can be routed back.
self.callback_registry
.register(
correlation_id.clone(),
CallbackMetadata {
tool_name: "wallet_transact".to_string(),
user_id: ctx.user_id.clone(),
thread_id,
channel,
},
)
.await;

let mut result = json!({
"status": "pending",
"correlation_id": correlation_id,
"to": to,
"value": value,
"message": "Transaction submitted to wallet for approval. \
The wallet owner must confirm on their device."
});

if data.is_some() {
result["data_present"] = json!(true);
}
if let Some(cid) = chain_id {
result["chain_id"] = json!(cid);
}

Ok(ToolOutput::success(result, start.elapsed()))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The wallet_transact tool registers a callback and returns a pending status, but it never actually initiates the transaction request via self.session.request(). This makes the tool non-functional as the wallet will never receive the signature request. The tool should initiate the request (potentially in a background task if it blocks) before returning the correlation ID.

Comment thread Cargo.toml Outdated
hex = "0.4.3"

# WalletConnect v2 client for Ethereum transaction approval
walletconnect-client = { git = "https://github.com/zmanian/walletconnect-client.git", branch = "main", default-features = false, features = ["native"] }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pin git dependencies to a specific commit hash (rev) for reproducible builds, as per the general rules of this repository.

References
  1. Pin git dependencies to a specific commit hash (rev) for reproducible builds.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
Comment on lines +79 to +80
Ok(event) => event,
Err(e) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When wc.next().await returns Ok(None), it indicates that the stream has ended. The current implementation returns None but leaves the client in the Option, causing the listener loop to enter a busy-wait state (polling every 100ms and getting None immediately). The client should be cleared (*client = None) when the stream ends to stop the polling.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
if let Some(ref wc) = *client {
let address = wc
.get_account()
.map(|a| format!("{a:?}"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using format!("{a:?}") to extract the account address is unreliable as it depends on the Debug implementation of the AccountId type, which often includes the chain ID prefix (e.g., eip155:1:0x...). It is better to use a specific method to extract just the address string if available in the walletconnect-client API.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b397d21bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tools/builtin/ethereum/transact.rs Outdated
Comment on lines +142 to +146
self.callback_registry
.register(
correlation_id.clone(),
CallbackMetadata {
tool_name: "wallet_transact".to_string(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Submit wallet transaction before returning pending status

wallet_transact currently records callback metadata and returns a pending success payload, but never calls WalletConnectSession::request (or any other dispatch path) to actually send eth_sendTransaction. In this commit there is no production code path that consumes the correlation ID to submit the transaction, so users can receive a “submitted for approval” response while nothing was sent and the callback only times out.

Useful? React with 👍 / 👎.

Comment thread src/tools/builtin/ethereum/session.rs Outdated
Comment on lines +83 to +85
if matches!(e, WalletConnectError::Disconnected) {
*client = None;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset paired state when WalletConnect drops with error

When wc.next() returns WalletConnectError::Disconnected, the code clears wc_client but does not update status, so the session can remain Paired even though no client exists. In that state, pairing/transaction flows can behave inconsistently (e.g., appearing paired first, then failing later), and users may be blocked from cleanly re-pairing after a disconnect.

Useful? React with 👍 / 👎.

@zmanian zmanian force-pushed the fix/mcp-client-bugs branch from 4b397d2 to 95c41b3 Compare April 12, 2026 23:23
@github-actions github-actions bot added the size: L 200-499 changed lines label Apr 12, 2026
Comment thread src/cli/mcp.rs
// Truncate long descriptions (char-safe to avoid
// panicking on multi-byte UTF-8 boundaries, #1947)
let desc = if tool.description.chars().count() > 60 {
let truncated: String = tool.description.chars().take(57).collect();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical — Pre-existing byte-slicing panic (same pattern)

This PR fixes byte-level truncation in test_server, but crates/ironclaw_tui/src/widgets/conversation.rs:863 has the same pattern: .truncate(57) which is byte-based and will panic on multi-byte UTF-8. The PR's own philosophy ("fix the pattern") demands fixing all instances.

Suggested fix: Also fix items_str.truncate(57) in conversation.rs to use chars().take() or floor_char_boundary(), matching the pattern used in this PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dcbb7a7. The .truncate(57) in conversation.rs now uses char_indices() to find a safe truncation point, preventing panics on multi-byte UTF-8 characters.

Comment thread src/tools/mcp/config.rs
///
/// This allows users to supply descriptive names like "My Twitter Server"
/// which are stored as `my_twitter_server` (#2236).
pub fn normalize_server_name(name: &str) -> String {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — Server-side API handler bypasses name normalization

src/channels/web/handlers/extensions.rs:143 passes req.name directly to ext_mgr.install() without normalization. The JS frontend normalizes on the client side, but direct API calls (e.g., curl) bypass the JS and send raw names. This creates client-server divergence.

Suggested fix: Apply normalize_server_name() on the server side in extensions_install_handler for mcp_server kind, or add name format validation to McpServerConfig::validate().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dcbb7a7. The web API extensions_install_handler now applies normalize_server_name() to req.name before passing to ext_mgr.install().

Comment thread src/cli/mcp.rs
anyhow::bail!("Server name '{}' contains no valid characters", raw_name);
}
if name != raw_name {
println!(" (normalized name: '{}' -> '{}')", raw_name, name);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Lookup commands don't normalize names

remove_server, auth_server, test_server, and toggle_server do NOT normalize the user-provided name. If a user adds "My Twitter Server" (stored as my_twitter_server), then runs ironclaw mcp test "My Twitter Server", the lookup fails with "Server not found" and no hint about the normalized name.

Suggested fix: Either normalize in lookup commands too (with a "did you mean 'my_twitter_server'?" fallback), or include the normalized form in the error message when lookup fails.

Comment thread src/cli/mcp.rs
/// byte index 57 falls inside a character boundary would panic with
/// `&tool.description[..57]`.
#[test]
fn test_tool_description_truncation_multibyte_utf8() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Tests duplicate logic instead of testing production code

The truncation regression tests (test_tool_description_truncation_multibyte_utf8, test_tool_description_truncation_ascii) duplicate the truncation logic inline rather than calling the actual production code path. If someone changes the production truncation logic (e.g., changes 57 to 50), these tests would still pass.

Suggested fix: Extract the truncation logic into a helper function used by both test_server and the tests, or write the tests to call test_server with a mock client.

Comment thread src/tools/mcp/config.rs
// Trim trailing underscore
if result.ends_with('_') {
result.pop();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Leading/trailing hyphens not trimmed

normalize_server_name trims leading/trailing underscores but not hyphens. Input "-hello-" produces "-hello-", and consecutive hyphens are not collapsed. Names like --- would produce ---, which could confuse downstream systems using the name in file paths or secret keys (e.g., mcp_---_access_token).

Suggested fix: Treat hyphens symmetrically: also collapse consecutive hyphens and trim leading/trailing hyphens, or document the intentional asymmetry. At minimum, add test cases for "-hello-", "---", and "a--b" to pin the behavior.

Copy link
Copy Markdown
Collaborator

@serrrfirat serrrfirat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paranoid Architect Review — REQUEST CHANGES

1 Critical, 1 High, 3 Medium findings.

The three bug fixes (normalization, UTF-8 truncation, auth skip) are solid and well-tested. However:

  • Critical: A pre-existing .truncate(57) byte-slicing panic exists in conversation.rs — the same pattern this PR fixes. Fix the pattern, not just the instance.
  • High: The server-side API handler bypasses name normalization, so direct API calls can inject unnormalized names.
  • Medium: Lookup commands don't normalize, tests duplicate logic instead of testing production code, and hyphens are treated asymmetrically with underscores.

@zmanian zmanian requested a review from serrrfirat April 14, 2026 08:55
Comment thread src/cli/mcp.rs
} = args;

// Normalize the server name: lowercase, replace special chars with
// underscores so descriptive names like "My Twitter Server" work (#2236).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High Severity — CLI lookup commands don't normalize the name argument

add_server normalizes the name here, but the four other CLI sub-commands — remove_server (line 292), auth_server (line 418), test_server (line 502), and toggle_server (line 625) — all use the raw user-supplied name for servers.get(&name) / servers.remove(&name).

After this PR, ironclaw mcp add "My Server" https://... stores the server as my_server. But ironclaw mcp remove "My Server" would fail with "Server not found" because the stored name is my_server and the lookup is an exact string match.

Suggested fix: Add let name = normalize_server_name(&name); at the top of remove_server, auth_server, test_server, and toggle_server, matching what add_server does here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bc3f863. All four CLI lookup commands (remove_server, auth_server, test_server, toggle_server) now normalize the name argument before lookup, matching add_server behavior.

_ => None,
});

let normalized_name = crate::tools::mcp::config::normalize_server_name(&req.name);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium Severity — Missing empty-name guard after normalization

normalize_server_name("@#$") returns "". The CLI's add_server checks for this (line 186–188) and returns an error, and the JS frontend checks if (!name) too. But this web handler passes the empty string straight to ext_mgr.install().

A direct API caller (bypassing the JS frontend) could create a server with an empty name.

Suggested fix:

let normalized_name = crate::tools::mcp::config::normalize_server_name(&req.name);
if normalized_name.is_empty() {
    return Ok(Json(ActionResponse::fail(
        "Server name contains no valid characters".to_string(),
    )));
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bc3f863. Added empty-name guard after normalization in the web API handler. Direct API callers sending names like "@#$" now get a clear error instead of creating a server with an empty name.

zmanian and others added 4 commits April 14, 2026 11:20
…th when header set (#2236, #1947, #1948)

- Server names are now auto-normalized (lowercase, special chars to
  underscores) so descriptive names like "My Twitter Server" are
  accepted as "my_twitter_server" instead of rejected.

- Tool description truncation in `mcp test-server` uses char-safe
  iteration instead of byte-index slicing, preventing panics when
  index 57 falls inside a multi-byte UTF-8 character.

- `requires_auth()` now returns false when the user has already
  configured a custom Authorization header, avoiding unnecessary
  OAuth/DCR flows for API-key-authenticated servers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review feedback:
- Fix pre-existing .truncate(57) byte-slicing panic in
  conversation.rs by using char_indices for safe truncation
- Add normalize_server_name() to the web API extensions install
  handler, preventing unnormalized names via direct API calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add normalize_server_name() to McpServerConfig constructors and
McpServersFile::upsert() so names are consistently normalized regardless
of entry point (CLI, API, JSON config). This complements the previous
commit's handler-level normalization with defense-in-depth at the data
layer.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address follow-up review feedback:
- Add normalize_server_name() to remove_server, auth_server,
  test_server, and toggle_server CLI commands so lookups match
  the normalized name stored by add_server
- Add empty-name guard in web API extensions install handler to
  reject names like "@#$" that normalize to ""

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zmanian zmanian force-pushed the fix/mcp-client-bugs branch from bc3f863 to 65e35a8 Compare April 14, 2026 11:20
@github-actions github-actions bot added the scope: agent Agent core (agent loop, router, scheduler) label Apr 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: dependencies Dependency updates scope: tool/builtin Built-in tools scope: tool/mcp MCP client scope: tool Tool infrastructure size: L 200-499 changed lines

Projects

None yet

3 participants